Перейти к основному содержимому

5.02. Асинхронность и многопоточность

Разработчику Архитектору

Асинхронность и многопоточность

Python, как и JavaScript, изначально проектировался как однопоточный язык в контексте выполнения пользовательского кода. Однако это утверждение требует уточнений: оно справедливо только в рамках одного процесса и при работе с глобальной блокировкой интерпретатора (GIL). Чтобы понять архитектурные возможности Python в области параллелизма и конкурентности, необходимо разграничить три ключевые концепции: многопоточность, многопроцессность и асинхронность. Каждая из них решает задачи эффективного использования ресурсов, но делает это разными способами, с разной семантикой и в разных условиях.

Процесс — это независимое пространство выполнения, обладающее собственной памятью, файловыми дескрипторами и системными ресурсами. Процессы изолированы друг от друга: обмен данными между ними требует явных механизмов межпроцессного взаимодействия (IPC), таких как каналы, очереди или общая память.

Поток (thread) — это последовательность исполнения внутри процесса. Все потоки одного процесса разделяют его адресное пространство, что позволяет им легко обмениваться данными через общие переменные. Однако эта общность также порождает риски гонок данных (race conditions), требующие синхронизации с помощью блокировок (locks) и других примитивов.

В большинстве языков высокого уровня (например, Java, C#) потоки являются настоящими системными потоками (native threads), управляемыми операционной системой. Python предоставляет доступ к таким потокам через модуль threading. Однако здесь возникает важная особенность. CPython — стандартная и наиболее распространённая реализация Python — содержит глобальную блокировку интерпретатора (Global Interpreter Lock, GIL). Это мьютекс, который гарантирует, что в каждый момент времени только один поток выполняет байт-код Python.

На практике это означает то, что в программе с несколькими потоками не может происходить истинный параллелизм выполнения Python-кода. Даже если система имеет несколько ядер CPU, все потоки CPython поочерёдно захватывают GIL, выполняя инструкции по одной. Параллелизм достигается только на уровне переключения контекста между потоками, но не на уровне одновременного выполнения.

GIL существует для защиты внутренних структур данных интерпретатора (например, счётчика ссылок) от повреждения при одновременном доступе из нескольких потоков. Он является компромиссом между производительностью в однопоточных сценариях и сложностью многопоточной безопасности. GIL не блокирует выполнение всех операций. Когда поток выполняет операции, не связанные с интерпретатором (например, ввод-вывод, вызовы C-расширений, работу с сетью), он освобождает GIL, позволяя другим потокам работать. Таким образом, GIL не делает многопоточность бесполезной — она остаётся полезной для I/O-bound задач, но почти бесполезна для CPU-bound задач.

Модуль threading предоставляет высокоуровневый API для работы с потоками. Создание потока:

import threading

def worker():
print(f"Работаю в потоке {threading.current_thread().name}")

thread = threading.Thread(target=worker)
thread.start()
thread.join()

Потоки в Python подходят для задач, где основное время уходит на ожидание внешних событий, таких как операции ввода-вывода (чтение файлов, сетевые запросы), взаимодействие с медленными устройствами, ожидание ответа от API, баз данных и т.д.

Однако из-за GIL они неэффективны для распараллеливания вычислений. Например, расчёт миллиона значений функции в десяти потоках не ускорится — все потоки будут по очереди захватывать GIL, и суммарное время выполнения будет близко к однопотовому варианту.

Для CPU-интенсивных задач используется альтернатива — многопроцессность.

Модуль multiprocessing позволяет запускать Python-код в отдельных процессах. Каждый процесс имеет свой собственный интерпретатор и, соответственно, свой GIL. Это позволяет достичь истинного параллелизма на многоядерных системах.

from multiprocessing import Process

def compute_square(n):
result = n ** 2
print(f"{n}^2 = {result}")

p = Process(target=compute_square, args=(5,))
p.start()
p.join()

Каждый процесс живёт в своей памяти, поэтому обмен данными требует сериализации (например, через Queue, Pipe или Manager). Это накладывает издержки, но оправдано для задач, где выигрыш от параллелизма превышает затраты на передачу данных. Таким образом, многопроцессность — это путь к параллелизму в Python, особенно для CPU-bound задач. Однако она требует больше ресурсов и сложнее в управлении по сравнению с потоками.

Если многопоточность и многопроцессность основаны на параллелизме (физическом или логическом разделении выполнения), то асинхронность строится на принципе конкурентности (concurrency) — множественные задачи выполняются "почти одновременно", но не обязательно параллельно.

Асинхронность в Python реализована через событийный цикл (event loop) и корутины (coroutines). Основной модуль — asyncio.

Корутина — это специальная функция, которая может приостанавливать своё выполнение и передавать управление обратно в событийный цикл, не блокируя поток. Она объявляется с ключевым словом async:

import asyncio

async def fetch_data():
print("Начинаю загрузку...")
await asyncio.sleep(1) # Имитация сетевой задержки
print("Данные получены")
return {"data": 42}

Вызов такой функции не запускает её выполнение — он возвращает объект корутины. Чтобы запустить корутину, её нужно запланировать в событийном цикле:

async def main():
task = asyncio.create_task(fetch_data())
print("Задача запущена, продолжаю...")
result = await task
print("Результат:", result)

asyncio.run(main())

Ключевое слово await указывает, что в этой точке корутина может быть приостановлена до завершения асинхронной операции (например, сетевого запроса). Во время ожидания событийный цикл может переключиться на выполнение других корутин.

Событийный цикл — это центральный диспетчер, управляющий выполнением корутин. Он работает по следующему принципу:

  1. Поддерживает очередь готовых к выполнению задач (корутин).
  2. Запускает одну задачу.
  3. Если задача встречает await на незавершённой асинхронной операции (например, ожидание сети), цикл приостанавливает её и сохраняет состояние.
  4. Переключается к другой готовой задаче.
  5. Когда асинхронная операция завершается (например, получен ответ от сервера), цикл возобновляет соответствующую корутину.

Это аналогично Event Loop в JavaScript, но с одним важным отличием: в Python вы управляете циклом явно (через asyncio.run, get_event_loop и т.д.), и он работает в одном потоке.

Центральное различие между синхронным и асинхронным кодом — в характере операций.

Блокирующая операция — приостанавливает выполнение потока до завершения. Пример: time.sleep(1), requests.get(...).

Неблокирующая (асинхронная) операция — регистрирует намерение выполнить действие и сразу возвращает управление. Результат будет обработан позже, через callback или await. Пример: asyncio.sleep(1), aiohttp.get(...).

Важно: использование блокирующих операций внутри корутин блокирует весь событийный цикл, нарушая преимущества асинхронности. Такие операции следует либо заменять на асинхронные аналоги, либо выполнять в пуле потоков:

# Выполнение блокирующей функции без остановки цикла
result = await asyncio.get_event_loop().run_in_executor(None, blocking_function, arg)

Когда использовать асинхронность?

Асинхронность — это более легковесный и контролируемый способ достижения конкурентности, но требует изменения стиля программирования: весь стек должен быть асинхронным. Асинхронность наиболее эффективна в I/O-bound сценариях с большим количеством параллельных операций - веб-серверы с тысячами соединений (FastAPI, aiohttp), клиенты с множеством HTTP-запросов, работа с базами данных (aiomysql, asyncpg), обработка сетевых протоколов (WebSocket).

Для CPU-bound задач асинхронность не даёт выигрыша, так как нельзя «приостановить» вычисления. Здесь предпочтительнее многопроцессность.

Стандартный событийный цикл asyncio реализован на Python и имеет определённые накладные расходы. Для повышения производительности можно использовать uvloop — альтернативную реализацию цикла на Cython, использующую библиотеку libuv (ту же, что и Node.js).

import asyncio
import uvloop

uvloop.install() # Устанавливает uvloop как default event loop
asyncio.run(main())

uvloop может ускорить выполнение асинхронных программ в 2–4 раза, особенно при большом числе одновременных соединений.

Выбор между потоками, процессами и асинхронностью зависит от характера задачи:

  • CPU-bound задачи → multiprocessing
  • I/O-bound с небольшим числом операций → threading
  • I/O-bound с высокой нагрузкой (тысячи соединений) → asyncio
  • Гибридные задачи → комбинация (например, асинхронный цикл с пулом процессов для вычислений)

Python предоставляет все три механизма, и их правильное применение позволяет эффективно использовать ресурсы системы. Главное — понимать, что асинхронность не является многопоточностью, а представляет собой другую модель организации выполнения, ориентированную на конкурентность, а не на параллелизм.